atelet: emit per-actor usage events from the stats sweep - #1206
Conversation
Jeff Luo (JeffLuoo)
left a comment
There was a problem hiding this comment.
Could you update docs/observability.md in the logging section to include instructions for using the new per-actor usage events?
We can update doc in a separate PR after implementation is finalized and submitted. Otherwise, we will need to maintain sync of implementation and doc which is not very efficient. |
Da Huang (git286)
left a comment
There was a problem hiding this comment.
Actor Suspend -> Resume latency (sub-second) is one of the most important performance SLO the project is trying to achieve. Right now the sampleFirst sit in the critical path synchronously and could impact the latency in the unhappy case (I see the timeout is 10 seconds for worst case).
I think we should make the sampling async to reduce the impact on the critical path latency.
b49fe59 to
91e3fff
Compare
Done in 91e3fff:
|
|
I would double check with Benjamin Elder (@BenTheElder) to make sure that the synchronous sampling logic in the running -> suspend path is acceptable. (Happy case few ms, but worst case 10s latency?) (Given the first sampling logic has become async so suspend -> running latency won't be affected) |
Yikes, 10s in the hot path is way outside of our targets, what other options did we consider? |
The final sample has to be a blocking call, otherwise, it potentially gets lost forever (cgroup got destroyed). However, we can decrease the |
So in theory we want say, 100ms, but realistically we are something at least XXXms for gVisor operations, and slower for uVM at the moment. Slowing that down in any way is going the wrong direction as we're already pretty far from the desired latency. Can we just sample while it's online and not sample during snapshot etc. |
The periodic resource event emission during normal operation has already been implemented in this PR. However, the final resource utilization event is very important before actor suspend. This event contains the metric of total CPU usage of current actor session. The periodic resource event is unable to provide this information accurately. Alternatively, we can make this operation async (best effort) so that it does not block the hot path. WDYT? |
|
I don't think async works here. The whole point of the final sample is that it reads the CPU counter before the checkpoint destroys it. If we fire-and-forget, the read races the teardown and loses most of the time, so we'd ship the feature with its main guarantee broken. Correct me if I am wrong, but I think we don't have to choose between latency and the sample. The Checkpoint handler already does a bunch of prep before dispatching to the ateom (read sandbox record, ensure assets, dial, build spec). We can start the sample in a goroutine at the top of the handler and join it right before dispatch. The read takes single-digit ms and the prep takes way longer, so in the happy case it's already done by the time we need it with zero added latency, not just "small". If it's somehow still not done at dispatch time, we wait a short grace (say 50ms), then log the lost-sample warning and proceed. So the worst case drops from 10s to 50ms, and only when the stats path is already broken. Longer term the clean fix is to have ateom take the sample itself as the first step of CheckpointWorkload and return it in the response, that's literally "sample while it's online" with zero added latency and no race at all. But it's a proto change touching both runtimes, so I'd do that as a follow-up. WDYT about overlap + short grace in this PR, piggyback as follow-up? |
The overlap with a short grace period makes sense to me, but I have reservations about the long-term approach. I considered moving the final sampling to ateom before; however, if our goal is zero overhead on checkpoint operations, blocking the process to collect metrics will inevitably introduce latency—regardless of whether it's handled in atelet via a dedicated RPC or embedded directly into the checkpoint RPC via ateom. |
One thing that hasn't come up in this thread: the latency question aside, there's a correctness bug in the current ordering that the ateom approach would fix for free. Right now we emit the final sample before calling CheckpointWorkload. If the checkpoint fails transiently, the workload keeps running and the control plane retries so we emit a second final for the same session. Anyone summing finals now double counts almost the whole session, and the events carry no epoch/attempt id to dedup on. If ateom takes the sample as part of CheckpointWorkload and returns it in the response, this problem can't happen: we only emit when the checkpoint actually succeeded, so it's always exactly one final per session. Same trick would work for Terminate, which currently emits no final at all. So I'd frame the follow-up as a correctness fix, not a latency optimization. On the latency concern: the read is cheap on gVisor, and ateom can overlap it with its own pre-destructive prep, same as what we're doing here. So it doesn't have to add anything to the critical path. |
Updated in the latest commits — the final sample now follows the overlap shape proposed above, tightened one step further:
Net latency: zero added in the happy case; worst case 50ms, paid only when the stats path is already broken. The read timeout (500ms) bounds only a background goroutine, never the handler. On the ateom-side follow-up: with emit-on-success in atelet, the duplicate-final correctness issue is fixed here, so what the proto change would still buy is exact-at-freeze accuracy (the current design undercounts by the CPU burned between read and freeze — prep-length on a session-length measurement) and dropping the 50ms grace. The Terminate gap doesn't need it either: the same begin/join/emit pattern fits the Terminate handler as a small follow-up. |
Update doc in a follow up PR
The events channel is the per-actor half of the usage telemetry split: the poller's metrics aggregate to the bounded template-level label set, and everything carrying actor or atespace identity travels here instead, as structured log events -- never a TSDB series. Each executing actor's sample from the poller's existing sweep becomes one JSON record on atelet's stdout, using the same label vocabulary as actorlog's lifecycle events (ateattr.ActorLogLabels, including the GCE logging.googleapis.com/labels spelling), so one log filter on the actor uid returns an actor's container logs, lifecycle transitions, and usage samples interleaved. Identity comes solely from each sample's echo, per the stats RPCs' attribution contract; the measurements ride as payload fields, stamped with an event kind so future kinds can join without reshaping the record. Idle workers emit nothing: an idle fleet is silent by design. Events also carry the ate.workerpool.namespace/name pair the metric labels already carry, resolved by the sweep's own pod list, so a pool-level metric spike can pivot to the actors behind it -- pool membership lives on the worker pod and is unrecoverable from logs once the pod is gone, so it must be stamped at emission. An unresolved pod emits without the pair, following the metric channel's rule. The sweep feeds both channels from the same probe, so the events add no RPC load, and the one knob governs both: --actor-stats-poll-interval 0 disables the subsystem.
a7240ce to
10986de
Compare
|
Updated the PR, removed the lifecycle event and only keep periodic ones. |
…azily Two emitter-construction fixes from review. Usage events are a data feed, not leveled diagnostics: quieting a node with --log-level=warn must not silently sever them, so the emitter now writes through its own fixed-level handler instead of the serverboot logger, and the subsystem's one off-switch stays --actor-stats-poll-interval=0. The records still carry level INFO on the wire, so nothing downstream changes. metadata.OnGCE probes the metadata server -- seconds of timeout off GCE -- and was called synchronously on atelet's boot path just to pick the label-group key. The key now resolves once, at first emit, on the poller's sweep goroutine, which nobody waits on.
Two follow-ups on the usage-event emitter from review. The label group's spelling was chosen in two places -- actorlog picked it for container logs and lifecycle events, the usage emitter picked it again for itself. Export actorlog.LabelsKey as the one place the choice lives, so every emitter of the actor-identity label group promotes into Cloud Logging the same way, and going vendor-neutral later means changing one function. The lazy resolution moved the metadata probe off atelet's boot path but onto the first emit. Warm it from startStatsPoller on a throwaway goroutine instead: sync.OnceValue lets an emit that arrives first simply wait for the in-flight probe, so neither the boot path nor the sweep pays for it.
# Conflicts: # cmd/atelet/statspoller_test.go
|
One robustness concern: emit writes to stdout synchronously inside the sweep's errgroup, and a write to a full pipe blocks forever, since there's no timeout on stdout writes. So if the log consumer stalls (disk full, log rotation stuck), one stuck write wedges g.Wait(), the tick loop stops, and the metrics freeze too. They'll keep re-serving the last snapshot with no error. Before this PR the sweep never wrote to stdout, so log-pipe health and metric health were independent; this couples them, and it bites exactly during disk incidents when you'd be looking at these dashboards. Suggestion: make the emitter non-blocking. A small bounded channel drained by one writer goroutine, dropping and counting when full, would do it. Dropping is safe here since these are cumulative samples: the next healthy tick repairs the gap. Losing events when stdout is dead is unavoidable anyway; losing the metrics with them isn't. |
Two review findings on the events channel. The emitter wrote to stdout synchronously inside the sweep's errgroup, and a write to a full pipe blocks forever: one stalled log consumer (wedged rotation, disk-full fallout) would park a probe, wedge the sweep, and silently freeze the metrics channel that shares it -- the gauges would keep re-serving the last snapshot with no error, during exactly the incident those dashboards exist for. Before the events channel the sweep performed no stdout writes (its own logging is debug-level and the metrics leave via OTLP), so log-pipe health and metric health were independent; an asyncWriter restores that: a bounded queue drained by one goroutine, dropping and counting when full, with the stall reported once the stream proves itself live again. Dropping is safe because the samples are point-in-time readings the next healthy tick repairs; when stdout is dead the events are lost either way -- the choice is whether the metrics die with them. The pool resolver listed pods by the ate.dev/worker-pool key's presence, and an existence selector matches empty-valued labels too -- anyone can stamp a bare key in YAML -- so a half pair (namespace set, name empty) could enter the map and emit an empty-string ate.workerpool.name label, on events and metrics both. Half a pair names no pool: it is now skipped at ingestion, the resolver being the sole producer of refs, so absent and unresolvable are the same unlabeled answer everywhere downstream.
Done in 32549be, as suggested: Tests cover the contract directly: a wedged underlying writer with a burst past capacity (every write returns immediately, drop count exact, everything not dropped drains on recovery — conservation-checked) and that queued records are copies, since slog reuses its buffer. |
|
The async queue fixes blocking, but the logger and this drain goroutine are still two uncoordinated writers on stdout. Today that works only because every record stays under PIPE_BUF (4 KB), which is an accident of current field sizes, not a guarantee. Add a field or point stdout at a file and lines can tear. Suggest wrapping stdout in actorlog.NewSyncedWriter once, handing it to serverboot.InitLoggerWithWriter and to newAsyncWriter, the same way ateom-gvisor and ateom-microvm already do. The queue keeps the sweep off the critical path, and the lock makes interleaving impossible regardless of size or destination. |
The runtime logger and the usage-event drain were two uncoordinated writers on stdout, tear-free only while every record fits a pipe's atomic-write size -- an accident of field sizes, not a contract. One actorlog.SyncedWriter now fronts stdout for both, the same pattern the ateoms use for their actor-log forwarders. The asyncWriter's queue still keeps a stalled log consumer from costing the sweep anything; the shared lock only guarantees whole records. The drop report also moves onto the emitter's own fixed-level pipeline: the loss signal is part of the feed's integrity, so it must be exactly as unkillable by --log-level as the feed itself -- routing it through the leveled logger meant a node quieted to error would lose the only evidence that events were dropped. Also from review of the current shape: document the actual bounds on the labels-key metadata probe (2s typical worst off GCE, 5s pathological cap, first-tick emits only), rename the asyncWriter receiver left over from its earlier name, give the writer tests cancelable contexts, and fix comments that had drifted from the implementation -- dialAteomStats served "both telemetry paths" until the lifecycle sampler was descoped, and the queue-depth comment oversized its own claim.
Done in 0fc0089, exactly as suggested: main builds one Same commit closes a related asymmetry you'd have found next: the drop report was routed through the leveled logger, so |
721152f
into
agent-substrate:main
Done, as the separate PR discussed above now that the implementation has settled: #1559 adds a |
Documents the per-actor usage events channel that #1206 added, closing the docs request from that review. New `Per-Actor Usage Events` section in the logging guide: - an example record and the consumer contract (filter on `msg` + `kind`; identity rides the same label group as lifecycle events and container logs, so the guide's existing query dimensions apply unchanged, and one `labels."ate.actor.uid"` filter returns an actor's output, transitions, and usage interleaved); - per-field semantics, including the part consumers must not get wrong: `memory_current_bytes`/`memory_working_set_bytes` are point-in-time, while `memory_peak_bytes`/`cpu_usage_usec` accumulate within an epoch whose boundary depends on `source` (cgroup restarts on restore, guest-agent survives it) — so window CPU is the increase between samples, never a sum; - the sampling knob (`--actor-stats-poll-interval`) and the delivery contract (best-effort behind a bounded queue, independent of `--log-level`); - the cardinality rule in bold: log-based metrics over these events must never label by actor identity. The metrics section now names the `ate.actor.stats.*` instruments in its registry pointer and cross-links here for per-actor detail. Every technical claim is checked against the code and the `WorkloadStatsSample` proto contract (epoch scoping, the `memory.peak`/Linux 5.19 caveat, trace-context absence, drop-warning text, flag semantics).
Part of #896 (Phase 1 of #550): the events channel — per-actor usage samples as structured log events. With this, both halves of #174's cardinality split exist: template-level metrics in the TSDB (#961), and everything carrying actor/atespace identity here, in the log store.
The events
One JSON record per executing actor per sweep on atelet's stdout, riding the poller's existing probe — no extra RPC load, and one knob governs both channels (
--actor-stats-poll-interval 0disables the subsystem). Idle workers emit nothing: an idle fleet is silent by design.Records use the same label vocabulary as actorlog's lifecycle events (
ateattr.ActorLogLabels, including the GCElogging.googleapis.com/labelsspelling) — so one Cloud Logging filter onlabels."ate.actor.uid"returns an actor's container logs, lifecycle transitions, and usage samples interleaved. Identity comes solely from each sample's echo, per the stats RPCs' attribution contract. Measurements ride as payload fields (kind, class, source, the four numbers,observed_at_unix_nano); thekindfield (today alwaysperiodic) lets future kinds join without reshaping the record.Events also carry the
ate.workerpool.namespace/namepair the metric labels already carry, resolved by the sweep's own pod list, so a pool-level metric spike can pivot to the actors behind it — pool membership lives on the worker pod and is unrecoverable from logs once the pod is gone, so it must be stamped at emission. An unresolved pod emits without the pair, following the metric channel's rule.Scope
Earlier revisions also took first/final lifecycle samples from the Run/Restore/Checkpoint handlers. Per the review discussion on suspend/resume latency, those are descoped from this PR: it now touches no lifecycle path at all. The bracket design (including taking the final sample inside ateom's CheckpointWorkload and echoing it in the response) moves to a follow-up under #896.
Isolation
The poller dials its own short-lived connection per probe (
dialAteomStats) and never touches the lifecycle RPCs' cached clients — after review on #961 the isolation is structural.Validated live on ate-dev
Periodic events read back from Cloud Logging via
labels."ate.actor.name", one per executing actor per minute, withate.actor.uidconfirmed promoted intoLogEntry.labels(filterable) — the claim only production could prove.Consumer note: aggregate freely in the log store, but a log-based metric built over these events must label only by the bounded set (template, class, source, pool) — promoting actor identity into a metric label would reintroduce exactly the cardinality #174 keeps out of the TSDB.
Part of #896. Part of #550.